package org.hepeng.workx.mybatis.interceptor;

import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.BaseExecutor;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.logging.Log;
import org.apache.ibatis.logging.LogFactory;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ResultMap;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.hepeng.workx.exception.ApplicationRuntimeException;

import java.io.File;
import java.lang.reflect.Field;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * mysql explain 拦截器
 * @author he peng
 */

@Intercepts({
        @Signature(type = Executor.class ,
                method = "query" ,
                args = {MappedStatement.class , Object.class , RowBounds.class ,
                        ResultHandler.class , CacheKey.class , BoundSql.class})
        , @Signature(type = Executor.class ,
                    method = "query" ,
                    args = {MappedStatement.class , Object.class , RowBounds.class ,
                            ResultHandler.class})}
)
public class MySQLExplainInterceptor implements Interceptor {

    private static final Log LOG = LogFactory.getLog(MySQLExplainInterceptor.class);
    private static final Object LOCK = new Object();
    private static final String DEFAULT_LOG_ROOT_PATH = System.getProperty("user.home") + File.separator + "mybatis";
    private static final DateFormat FILE_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd");
    private static final DateFormat LOG_MSG_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    private static final Map<String , MappedStatement> EXPLAIN_MAPPED_STATEMENTS = new ConcurrentHashMap<>();
    private static final Map<Path , FileChannel> EXPLAIN_LOG_FILES = new ConcurrentHashMap<>();
    private ExecutorService executorService;

    private Properties props;
    private Boolean isEnable = LOG.isDebugEnabled();

    @Override
    public Object intercept(Invocation invocation) throws Throwable {

        Executor executor = (Executor) invocation.getTarget();
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        Object parameter = invocation.getArgs()[1];
        RowBounds rowBounds = (RowBounds) invocation.getArgs()[2];
        BoundSql boundSql = ms.getBoundSql(parameter);
        String sql = boundSql.getSql();
        String explainSql = "EXPLAIN " + sql;

        if (sql.startsWith("EXPLAIN") || EXPLAIN_MAPPED_STATEMENTS.containsKey(explainSql)) {
            return invocation.proceed();
        }

        executorService.submit(() -> {
            if (LOG.isDebugEnabled()) {
                LOG.debug("MySQL Explain Execute SQL ==> " + explainSql);
            }

            MappedStatement explainMS = newExplainMappedStatement(ms);
            BoundSql explainBoundSql = new BoundSql(explainMS.getConfiguration(), explainSql, boundSql.getParameterMappings(), parameter);
            CacheKey cacheKey = executor.createCacheKey(ms, parameter, rowBounds, explainBoundSql);
//        for (String key : additionalParameters.keySet()) {
//            countBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
//        }

            List<MySQLExplainResult> explainResults = null;
            try {
                explainResults = executor.query(explainMS, parameter, (RowBounds)invocation.getArgs()[2], (ResultHandler)invocation.getArgs()[3] , cacheKey , explainBoundSql);
            } catch (SQLException e) {
                LOG.error("Execute EXPLAIN SQL [" + explainSql + "] Error ," , e);
            }

            if (Objects.nonNull(explainResults) && ! explainResults.isEmpty()) {
                log(explainResults , ms , sql);
            }
            EXPLAIN_MAPPED_STATEMENTS.put(explainSql , explainMS);
        });

        return invocation.proceed();
    }

    private void log(List<MySQLExplainResult> explainResults, MappedStatement mappedStatement , String sql) {

        String logMessage = makeLogMessage(explainResults , mappedStatement , sql);

        try {
            String databaseName = getDatabaseName(mappedStatement);
            String logRootPath = DEFAULT_LOG_ROOT_PATH;
            if (null != this.props) {
                String logPath = this.props.getProperty("logPath");
                if (StringUtils.isNotBlank(logPath)) {
                    logRootPath = logPath;
                }
            }
            File logDirectoryFile = new File(logRootPath);
            if (! logDirectoryFile.exists()) {
                if (! logDirectoryFile.mkdirs()) {
                    throw new RuntimeException("create directory [" + logRootPath + "] error");
                }
            }
            String logFilePath = logRootPath + File.separator + databaseName + "-explain-" + FILE_DATE_FORMAT.format(new Date()) + ".log";

            Path path = Paths.get(logFilePath);
            FileChannel fileChannel;
            if (! EXPLAIN_LOG_FILES.containsKey(path)) {
                fileChannel = FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND);
                EXPLAIN_LOG_FILES.put(path , fileChannel);
            } else {
                fileChannel = EXPLAIN_LOG_FILES.get(path);
            }
            byte[] bytes = logMessage.getBytes(Charset.forName("utf-8"));
            ByteBuffer buffer = ByteBuffer.allocate(bytes.length);
            buffer.put(bytes);
            buffer.flip();

            fileChannel.write(buffer);
        } catch (Exception e) {
            LOG.error("Explain Logging Error : " , e);
        }
    }

    private String makeLogMessage(List<MySQLExplainResult> explainResults, MappedStatement mappedStatement, String sql) {
        StringBuilder builder = new StringBuilder("[" + LOG_MSG_DATE_FORMAT.format(new Date()) + "]  ")
                .append(mappedStatement.getId())
                .append(" -> SQL [" + sql + "] -> Explain Result : ")
                .append(System.getProperty("line.separator"));
        for (MySQLExplainResult explainResult : explainResults) {
            builder.append("(id) ")
                    .append(explainResult.getId())
                    .append("    (select_type) ")
                    .append(explainResult.getSelectType())
                    .append("    (table) ")
                    .append(explainResult.getTable())
                    .append("    (partitions) ")
                    .append(explainResult.getPartitions())
                    .append("    (type) ")
                    .append(explainResult.getType())
                    .append("    (possible_keys) ")
                    .append(explainResult.getPossibleKeys())
                    .append("    (key) ")
                    .append(explainResult.getKey())
                    .append("    (key_len) ")
                    .append(explainResult.getKeyLen())
                    .append("    (ref) ")
                    .append(explainResult.getRef())
                    .append("    (rows) ")
                    .append(explainResult.getRows())
                    .append("    (filtered) ")
                    .append(explainResult.getFiltered())
                    .append("    (Extra) ")
                    .append(explainResult.getExtra())
                    .append(System.getProperty("line.separator"));
        }
        return builder.toString();
    }

    private String getDatabaseName(MappedStatement mappedStatement) {
        String databaseName = "";
        try {
            DatabaseMetaData metaData = mappedStatement.getConfiguration().getEnvironment().getDataSource().getConnection().getMetaData();
            String databaseUrl = metaData.getURL();
            String subUrl = databaseUrl.substring(0 , databaseUrl.indexOf("?"));
            databaseName = subUrl.substring(subUrl.lastIndexOf("/") + 1);
        } catch (SQLException e) {
            LOG.error("get database name error" , e);
        }
        return databaseName;
    }

    private MappedStatement newExplainMappedStatement(MappedStatement ms) {
        String msId = ms.getId() + "_EXPLAIN";
        MappedStatement.Builder builder = new MappedStatement.Builder(ms.getConfiguration(), msId, ms.getSqlSource(), ms.getSqlCommandType());
        builder.resource(ms.getResource());
        builder.statementType(ms.getStatementType());
        builder.timeout(ms.getTimeout());
        builder.parameterMap(ms.getParameterMap());
        builder.resultSetType(ms.getResultSetType());
        builder.cache(ms.getCache());
        builder.flushCacheRequired(ms.isFlushCacheRequired());
        builder.useCache(ms.isUseCache());
        List<ResultMap> resultMaps = new ArrayList<>();
        ResultMap resultMap = new ResultMap.Builder(ms.getConfiguration(), ms.getId(), MySQLExplainResult.class, new ArrayList<>(0)).build();
        resultMaps.add(resultMap);
        builder.resultMaps(resultMaps);

        return builder.build();
    }

    @Override
    public Object plugin(Object target) {
        if (! this.isEnable) {
            return target;
        }
        synchronized (LOCK) {
            if (target instanceof Executor) {
                Class<?> superClass = target.getClass().getSuperclass();
                if (superClass == BaseExecutor.class) {
                    try {
                        Configuration conf = getConfigurationFromBaseExecutor((BaseExecutor) target);
                        DatabaseMetaData metaData = conf.getEnvironment().getDataSource().getConnection().getMetaData();
                        String databaseUrl = metaData.getURL();
                        if (databaseUrl.toLowerCase().startsWith("jdbc:mysql")) {
                            Integer threadPoolSize = Integer.valueOf(StringUtils.defaultString(this.props.getProperty("threadPoolSize"), "2"));
                            this.executorService = Executors.newFixedThreadPool(threadPoolSize);
                            return Plugin.wrap(target , this);
                        }
                    } catch (Throwable t) {
                        LOG.error(MySQLExplainInterceptor.class.getName() + " init error." , t);
                    }
                }
            }
            return target;
        }
    }

    private Configuration getConfigurationFromBaseExecutor(BaseExecutor target) {
        Configuration conf;
        Class<BaseExecutor> baseExecutorClass = BaseExecutor.class;
        try {
            Field configurationField = baseExecutorClass.getDeclaredField("configuration");
            configurationField.setAccessible(true);
            conf = (Configuration) configurationField.get(target);
        } catch (ReflectiveOperationException e) {
            throw new ApplicationRuntimeException(e);
        }
        return conf;
    }

    @Override
    public void setProperties(Properties properties) {
        this.props = properties;
        this.isEnable = Boolean.valueOf(this.props.getProperty("isEnable"));
    }

    @Data
    private class MySQLExplainResult {

        private Integer id;
        private String selectType;
        private String table;
        private String partitions;
        private String type;
        private String possibleKeys;
        private String key;
        private String keyLen;
        private String ref;
        private Long rows;
        private Double filtered;
        private String extra;

    }
}
